Skip to content

Redesign fork choice abstractions#5249

Merged
brech1 merged 16 commits into
ethereum:masterfrom
mkalinin:fork-choice-refactor
May 27, 2026
Merged

Redesign fork choice abstractions#5249
brech1 merged 16 commits into
ethereum:masterfrom
mkalinin:fork-choice-refactor

Conversation

@mkalinin

@mkalinin mkalinin commented May 13, 2026

Copy link
Copy Markdown
Contributor

This proposal makes ForkChoiceNode a first class citizen since Phase0 and extends it in Gloas.

Motivation

  • Makes it easier to reason about fork choice changes introduced in Gloas and Gloas fork choice spec in general
  • Simplifies testing as e.g. get_head returns a ForkChoiceNode regadless of a fork, there is no need to
    check with the spec version when designing fork choice tests that are common for all versions
  • Makes the code that relies on the fork choice to be agnostic to Gloas changes when it operates
    over plain beacon block roots. A good example of it would be Fast Confirmation Rule spec.

Changes

There are no semantics changes in Phase0 and Gloas. However, there are minor changes in some Gloas function behavior.
We will go over each meaningful change below.

get_block_root_node

Introduced in Phase0 and modified in Gloas. This is a synthetic function that creates a ForkChoiceNode with a block_root that is a common ancestor of all nodes with the same block_root. This function is handy for fast confirmation rule and potentially other spec code relying on the fork choice but operating over a beacon block tree only which implies that that code is agnostic to other fork choice node attributes like payload status.

Alternative could be a default value for ForkChoiceNode.payload_status field in Gloas. But I preferred this abstraction to be more explicit.

The naming of this function can be improved.

UPD: Replaced with get_node_for_root introduced for the FCR only and get_fork_choice_node test helper.

get_ancestor

Accepts node: ForkChoiceNode as a parameter instead of root: Root.

The difference between existing Gloas version and this PR is as the following. When block.slot <= slot the existing version always returns a node with PAYLOAD_STATUS_PENDING payload status and root = block_root. The new version returns node as is in this case. When block.slot > slot the results of existing and new versions are equivalent.

Behavior of this function in a new Gloas version is equivalent to Phase0 except for the way it obtains a parent node. In gloas parent node is always created with payload status returned by get_parent_payload_status.

is_ancestor

Gets ancestor of a node at maybe_ancestor.slot and checks if they are equal.

In Gloas the above equality check is preserved and, if ancestor.root == maybe_ancestor.root, payload statuses of node and maybe_ancestor are checked. Returns True if payload statuses are equal or maybe_ancestor payload status is PAYLOAD_STATUS_PENDING.

get_checkpoint_block

Modified to use ForkChoiceNode.

get_supported_node

In Phase0 returns ForkChoiceNode(root=message.root).

In Gloas modified to return ForkChoiceNode(root=message.root, payload_status=payload_status), where payload_status is borrowed from message.payload_present in case when block.slot < message.slot and equal to PAYLOAD_STATUS_PENDING otherwise.

get_attestation_score

Modified to use is_ancestor(store, get_supported_node(store, store.latest_messages[i]), node) in Phase0 and Gloas.
In Phase 0 is_ancestor call result equals to get_ancestor(store, store.latest_messages[i].root, store.blocks[root].slot) == root.

In Gloas it has been handled by is_supporting_vote before which is deprecated by this PR. Now to the equivalence between existing and new versions:

Case 1. node.root == message.root. Note that in this case get_ancestor(store, supported_node, store.blocks[node.root].slot) that is called inside of is_ancestor will return ancestor == supported_node as store.blocks[supported_node.root].slot == store.blocks[node.root].slot). Note that maybe_ancestor in the new version equals to node in the existing version. With this in mind we proceed with the following table:

Case Existing version This PR
node.payload_status == PAYLOAD_STATUS_PENDING return True return node.payload_status == PAYLOAD_STATUS_PENDING which is True
node.payload_status != PAYLOAD_STATUS_PENDING and message.slot == block.slot return False supported_node.payload_status is always PAYLOAD_STATUS_PENDING as the block it supports is from the same slot as the message (as per get_supported_node), thus supported_node.payload_status != node.payload_status and node.payload_status != PAYLOAD_STATUS_PENDING which implies the result is False
node.payload_status != PAYLOAD_STATUS_PENDING and message.slot > block.slot return node.payload_status == (PAYLOAD_STATUS_FULL if message.payload_present else PAYLOAD_STATUS_EMPTY) supported_node.payload_status == node.payload_status is returned as node.payload_status == PAYLOAD_STATUS_PENDING is False, supported_node.payload_status = PAYLOAD_STATUS_FULL if message.payload_present else PAYLOAD_STATUS_EMPTY as per get_supported_node , thus the results are equivalent

Case 2. node.root != message.root.

If node.root and message.root are from the same slot then both versions return False because of a root mismatch.

Otherwise, node.root must be from a past slot (no attestations from the future are applied to the FC). Then ancestor = get_ancestor(store, message.root, block.slot) in the original version gives the same result as ancestor = get_ancestor(store, supported_node, store.blocks[node.root].slot) in the new version. And then exactly node.payload_status == PAYLOAD_STATUS_PENDING or node.payload_status == ancestor.payload_status is returned in both cases.

get_weight

Instead of relying on is_supporting_vote for proposer boost it relies on is_ancestor(store, proposer_boost_node, node). proposer_boost_node in Gloas is constructed with PAYLOAD_STATUS_PENDING. In Phase0 it is simply a ForkChoiceNode(root=store.proposer_boost_root).

get_node_children

This function is introduece to Phase0 and then overridden in Gloas.

get_head

Returns ForkChoiceNode in Phase0 and the new version. New version code is very similar to Phase0 except for initial node construction and introduction of get_payload_status_tiebreaker

Testing

  • Modifies all the existing tests and test format in places where get_head(store) and other fork choice functions are used
  • is_ancestor helper is introduced that works with both types, beacon block roots and fork choice nodes, and handles that inside of the function
  • make reftests=true test passes

@github-actions github-actions Bot added testing CI, actions, tests, testing infra phase0 gloas labels May 13, 2026
Comment thread specs/gloas/fork-choice.md Outdated
Comment thread specs/gloas/fork-choice.md Outdated

@jtraglia jtraglia left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like these changes! Most of my comments here are little nits.

Comment thread specs/gloas/fork-choice.md
Comment thread specs/gloas/fork-choice.md Outdated
Comment thread specs/gloas/fork-choice.md Outdated
Comment thread specs/gloas/fork-choice.md Outdated
Comment thread specs/gloas/fork-choice.md Outdated
Comment thread tests/core/pyspec/eth_consensus_specs/test/helpers/fork_choice.py
Comment thread specs/gloas/fork-choice.md
@jtraglia jtraglia changed the title Redesign Fork Choice abstractions Redesign fork choice abstractions May 20, 2026
@jtraglia

Copy link
Copy Markdown
Member

@mkalinin just waiting for @jihoonsong to finish his review of this. I've been chatting with him privately and he's expecting to finish tomorrow. After that feedback is resolved, I'm confident we will merge this PR 🙂

Comment thread specs/gloas/fork-choice.md Outdated

@jihoonsong jihoonsong left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great refactor such as the removal of is_supporting_vote. This is the first round, I'm going to give a second review on function comments in Gloas right after.

Comment thread specs/phase0/fork-choice.md Outdated
Comment thread specs/phase0/fork-choice.md Outdated
Comment thread specs/phase0/fork-choice.md Outdated
Comment thread specs/phase0/fork-choice.md
Comment thread specs/phase0/fork-choice.md
Comment thread specs/gloas/fork-choice.md Outdated
Comment thread specs/phase0/fork-choice.md Outdated
Comment thread specs/phase0/fork-choice.md Outdated
Comment thread specs/gloas/fork-choice.md Outdated
Comment thread specs/gloas/fork-choice.md Outdated

@jihoonsong jihoonsong left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good. I think it's good to merge once the nits are fixed. I will make a following PR to handle the note comments across forks, so we can accept them as-is IMO. I don't want to block this PR just for them.

Comment thread specs/gloas/fork-choice.md Outdated
Comment thread specs/gloas/fork-choice.md Outdated
Comment thread specs/gloas/fork-choice.md Outdated
mkalinin and others added 2 commits May 27, 2026 11:16
Co-authored-by: Jihoon Song <jihoonsong@users.noreply.github.com>

@jihoonsong jihoonsong left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work, thank you for cleaning fork choice spec! Looking forward to have the next PR on revamping get_weight() but no rush :)

@brech1 brech1 enabled auto-merge (squash) May 27, 2026 14:08
@brech1 brech1 merged commit 47b2561 into ethereum:master May 27, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gloas phase0 testing CI, actions, tests, testing infra

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants